iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 9

不要強迫依賴不需要的功能 - 介面隔離原則 (Interface Segregation Principle)

  • 分享至 

  • xImage
  •  

簡單介紹

今天我們來聊聊 SOLID 原則中的第四個原則 —— 介面隔離原則(Interface Segregation Principle, ISP)。這個原則由 Robert C. Martin(Uncle Bob)提出,主要是為了解決「胖介面」所帶來的問題。

介面隔離原則的核心思想是:

客戶端不應該被迫依賴它不使用的方法

換句話說,與其建立一個包含所有功能的大型介面,不如將其拆分為多個專門的小介面。每個客戶端只需要依賴它真正需要的介面,這樣可以降低耦合度並提高系統的靈活性。

值得一提的是,違反介面隔離原則會導致不必要的依賴關係。當一個介面包含太多方法時,實作該介面的類別就被迫要實作所有方法,即使其中某些方法對該類別來說毫無意義。這種設計會讓系統變得脆弱且難以維護。

接下來我們會透過一個多功能印表機系統的 TypeScript 範例,來了解如何避免臃腫的介面設計。

違反介面隔離原則(不良):臃腫的介面強迫實作不需要的方法

// 不好的範例:違反 ISP 的多功能設備介面
interface MultiFunctionDevice {
  // 列印功能
  print(document: string): void;
  
  // 掃描功能
  scan(): string;
  
  // 傳真功能
  fax(document: string, phoneNumber: string): void;
  
  // 影印功能
  copy(document: string): void;
  
  // 電子郵件功能
  sendEmail(document: string, email: string): void;
}

// 高階多功能印表機 - 可以實作所有功能
class PremiumPrinter implements MultiFunctionDevice {
  print(document: string): void {
    console.log(`列印文件: ${document}`);
  }
  
  scan(): string {
    console.log("掃描文件中...");
    return "掃描完成的文件內容";
  }
  
  fax(document: string, phoneNumber: string): void {
    console.log(`傳真文件 "${document}" 到 ${phoneNumber}`);
  }
  
  copy(document: string): void {
    console.log(`影印文件: ${document}`);
  }
  
  sendEmail(document: string, email: string): void {
    console.log(`將文件 "${document}" 寄送至 ${email}`);
  }
}

// 問題來了:基本印表機只需要列印功能
class BasicPrinter implements MultiFunctionDevice {
  print(document: string): void {
    console.log(`列印文件: ${document}`);
  }
  
  // 被迫實作不需要的功能,違反了 ISP!
  scan(): string {
    throw new Error("基本印表機不支援掃描功能");
  }
  
  fax(document: string, phoneNumber: string): void {
    throw new Error("基本印表機不支援傳真功能");
  }
  
  copy(document: string): void {
    throw new Error("基本印表機不支援影印功能");
  }
  
  sendEmail(document: string, email: string): void {
    throw new Error("基本印表機不支援電子郵件功能");
  }
}

// 掃描專用設備也有同樣的問題
class ScannerDevice implements MultiFunctionDevice {
  print(document: string): void {
    throw new Error("掃描器不支援列印功能");
  }
  
  scan(): string {
    console.log("高解析度掃描中...");
    return "高品質掃描文件";
  }
  
  // 又是一堆不需要的方法...
  fax(document: string, phoneNumber: string): void {
    throw new Error("掃描器不支援傳真功能");
  }
  
  copy(document: string): void {
    throw new Error("掃描器不支援影印功能");
  }
  
  sendEmail(document: string, email: string): void {
    throw new Error("掃描器不支援電子郵件功能");
  }
}

// 使用範例 - 會產生問題
const devices: MultiFunctionDevice[] = [
  new PremiumPrinter(),
  new BasicPrinter(),
  new ScannerDevice()
];

// 這樣的設計很危險,因為不是所有設備都支援所有功能
devices.forEach(device => {
  try {
    device.print("重要文件");
    device.scan(); // BasicPrinter 會拋出例外
  } catch (error) {
    console.log(`錯誤: ${error.message}`);
  }
});

問題分析

上面的設計違反了介面隔離原則,主要問題包括:

  1. 強制實作不需要的方法:每個設備都必須實作所有方法,即使它們不支援這些功能
  2. 拋出例外的方法:不支援的功能只能透過拋出例外來處理,這是不好的設計
  3. 緊耦合:所有設備都依賴於同一個龐大的介面
  4. 難以擴展:當要新增新功能時,所有實作類別都會受到影響

符合介面隔離原則(修正):將大介面拆分為多個專門的小介面

// 修正範例:符合 ISP 的分離介面設計

// 將大介面拆分為專門的小介面
interface Printable {
  print(document: string): void;
}

interface Scannable {
  scan(): string;
}

interface Faxable {
  fax(document: string, phoneNumber: string): void;
}

interface Copyable {
  copy(document: string): void;
}

interface Emailable {
  sendEmail(document: string, email: string): void;
}

// 基本印表機只實作需要的介面
class ImprovedBasicPrinter implements Printable {
  print(document: string): void {
    console.log(`基本印表機列印: ${document}`);
  }
}

// 掃描專用設備只實作掃描介面
class ImprovedScannerDevice implements Scannable {
  scan(): string {
    console.log("專業掃描器掃描中...");
    return "高解析度掃描結果";
  }
}

// 多功能印表機可以實作多個介面
class ImprovedPremiumPrinter implements Printable, Scannable, Faxable, Copyable, Emailable {
  print(document: string): void {
    console.log(`高階印表機列印: ${document}`);
  }
  
  scan(): string {
    console.log("高階印表機掃描中...");
    return "掃描完成的文件內容";
  }
  
  fax(document: string, phoneNumber: string): void {
    console.log(`傳真文件 "${document}" 到 ${phoneNumber}`);
  }
  
  copy(document: string): void {
    console.log(`影印文件: ${document}`);
  }
  
  sendEmail(document: string, email: string): void {
    console.log(`將文件 "${document}" 寄送至電子郵件`);
  }
}

// 有些設備可能只支援部分功能的組合
class PrinterWithScanner implements Printable, Scannable {
  print(document: string): void {
    console.log(`多功能印表機列印: ${document}`);
  }
  
  scan(): string {
    console.log("多功能印表機掃描中...");
    return "掃描文件內容";
  }
}

// 改進後的設備管理系統
class ImprovedDeviceManager {
  // 只對支援列印的設備進行列印操作
  printDocuments(printers: Printable[], documents: string[]): void {
    console.log("=== 列印作業開始 ===");
    printers.forEach((printer, index) => {
      documents.forEach(doc => {
        printer.print(`${doc} (設備 ${index + 1})`);
      });
    });
  }
  
  // 只對支援掃描的設備進行掃描操作
  scanDocuments(scanners: Scannable[]): string[] {
    console.log("=== 掃描作業開始 ===");
    return scanners.map((scanner, index) => {
      console.log(`使用設備 ${index + 1} 掃描:`);
      return scanner.scan();
    });
  }
  
  // 只對支援傳真的設備進行傳真操作
  sendFaxes(faxMachines: Faxable[], document: string, phoneNumbers: string[]): void {
    console.log("=== 傳真作業開始 ===");
    faxMachines.forEach(faxMachine => {
      phoneNumbers.forEach(phone => {
        faxMachine.fax(document, phone);
      });
    });
  }
}

// 使用範例 - 現在可以安全且彈性地運作
const deviceManager = new ImprovedDeviceManager();

// 建立各種設備
const basicPrinter = new ImprovedBasicPrinter();
const scanner = new ImprovedScannerDevice();
const premiumPrinter = new ImprovedPremiumPrinter();
const printerScanner = new PrinterWithScanner();

// 只有支援列印的設備會被用來列印
const printableDevices: Printable[] = [basicPrinter, premiumPrinter, printerScanner];
deviceManager.printDocuments(printableDevices, ["會議記錄", "報告書"]);

console.log("\n");

// 只有支援掃描的設備會被用來掃描
const scannableDevices: Scannable[] = [scanner, premiumPrinter, printerScanner];
const scanResults = deviceManager.scanDocuments(scannableDevices);

console.log("\n");

// 只有支援傳真的設備會被用來傳真
const faxableDevices: Faxable[] = [premiumPrinter]; // 只有高階印表機支援傳真
deviceManager.sendFaxes(faxableDevices, "緊急通知", ["02-1234-5678"]);

// 展示彈性:可以檢查設備是否支援特定功能
function demonstrateFlexibility(device: any) {
  console.log("\n=== 設備功能檢測 ===");
  
  if ('print' in device) {
    console.log("此設備支援列印功能");
    (device as Printable).print("測試列印");
  }
  
  if ('scan' in device) {
    console.log("此設備支援掃描功能");
    (device as Scannable).scan();
  }
  
  if ('fax' in device) {
    console.log("此設備支援傳真功能");
  } else {
    console.log("此設備不支援傳真功能");
  }
}

demonstrateFlexibility(basicPrinter);
demonstrateFlexibility(premiumPrinter);

改進重點說明

  1. 單一職責介面:每個介面只定義一種核心功能,符合單一職責原則
  2. 按需實作:各個設備類別只需要實作它們真正支援的介面
  3. 組合介面:複雜的設備可以實作多個介面來獲得多種功能
  4. 型別安全:TypeScript 的型別系統確保只有支援特定功能的設備才會被要求執行相應操作
  5. 易於擴展:新增功能時只需要建立新介面,不會影響現有程式碼

這時候我們可以發現,改進後的設計讓每個設備只需要關心它真正支援的功能,大大降低了系統的複雜度和耦合度。

總結

介面隔離原則的核心概念提醒我們:

  1. 介面應該精簡專一:每個介面應該專注於單一的功能領域
  2. 避免胖介面:不要建立包含過多方法的龐大介面
  3. 客戶端決定依賴:讓客戶端選擇它需要的介面,而不是被迫接受不需要的功能
  4. 組合勝於繼承:透過實作多個小介面來獲得複雜功能

需要注意的是,介面隔離原則與單一職責原則密切相關,但它們的關注點不同。單一職責原則關注類別的職責,而介面隔離原則關注介面的設計。

值得一提的是,在 TypeScript 中,介面隔離原則特別重要,因為 TypeScript 的結構性型別系統讓我們可以更靈活地組合介面。當我們遵循 ISP 時,可以充分利用 TypeScript 的型別推斷和聯合型別功能。

綜合以上所述,我們成功透過拆分介面實踐了介面隔離原則,讓系統中的每個元件只依賴它真正需要的功能,提高了系統的彈性和可維護性。

參考資料

上一篇
子類別應該能完美替換父類別 - 里氏替換原則 (Liskov Substitution Principle)
系列文
程式碼門診:診斷壞味道、開出重構處方9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言